C++ Primer 第4章 表达式

4.1 基础

4.1.1 基本概念

C++定义了运算符:

  • 一元运算符:作用于一个运算对象的运算符,如取地址符(&)和解引用符(*)
  • 二元运算符:作用于两个运算对象的运算符,如相等运算符(==)和乘法运算符(*)
  • 三元运算符:三目运算符
  • 函数调用也是一种特殊的运算符,对运算对象没有限制

C++语言定义了运算符作用于内置类型和复合类型的运算对象时所执行的操作。当运算符作用域类类型的运算对象时,可以重载运算符。我们使用重载运算符时,其包括运算对象的类型和返回值的类型,都是由运算符定义的,但是,运算对象的个数、运算符的优先级和结合律都是无法改变的。

C++的表达式要不然是右值,要不然就是左值

  • 在C语言里:左值可以位于赋值语句的左侧,右值则不能
  • 在C++语言里:
    • 当一个对象被用作右值的时候,用的是对象的值(内存)
    • 当对象被用作左值的时候,用的是对象的身份(在内存中的位置)

不同运算符对运算对象的要求各不相同,有的需要左值运算对象,有的需要右值运算对象。返回值也有差异,有的得到左值结果,有的得到右值结果。

左值可以当右值使用,这时候使用的是它的内容(值);但是不能把右值当成左值(也就是位置)使用。

需要用的左值的常见的运算符:

  • 赋值运算符需要一个(非常量)左值作为其左侧运算对象,得到的结果也仍然是一个左值
  • 取地址符作用于一个左值运算对象,返回一个指向该运算对象的指针,这个指针是一个右值
  • 内置解引用运算符、下标运算符、迭代器解引用运算符、string和vector的下标运算符,它们的求值结果都是左值
  • 内置类型和迭代器的递增递减运算符作用于左值运算对象,其前置版本所得的结果也是左值

使用关键字decltype的时候,左值和右值有所不同:如果表达式的求值结果是左值,decltype作用于该表达式(不是变量)得到一个引用类型:

  • 如果p是int *,则decltype(p)的结果是int&
  • 取地址运算符生成右值,则decltype(&p)的结果是int **,也就是说,结果是一个指向整型指针的指针

4.1.2 优先级与结合律

复合表达式是指含有两个或多个运算符的表达式。一般来说,表达式的值依赖于表达式的组合方式:

  • 高优先级运算符的运算对象要比低优先级运算符的运算对象更为紧密地组合在一起
  • 优先级相同,则其组合规则由结合律确定
    • 算术运算符满足左结合律,即从左向右的顺序组合运算对象
  • 括号无视优先级和结合律

优先级与结合律的影响:

  • 优先级会影响程序的正确性
  • 结合律对表达式产生影响的一个典型示例是输入输出运算

4.1.3 求值顺序

优先级规定了运算对象的组合方式,但是没有说明运算对象按照什么顺序求值,在大多数情况不会指定求值的顺序。

对于那些没有指定执行顺序的运算符来说,如果表达式指向并修改了同一个对象,将会引发错误并产生未定义的行为:

int i = 0;
cout << i << " " << ++i << endl;	//未定义的,表达式行为不可预知

有四种运算符明确规定了运算对象的求值顺序:

  1. 逻辑与(&&)运算符:先求左侧运算对象的值,只有当左侧运算对象的值为真时才继续求右侧运算对象的值
  2. 逻辑或(||)运算符
  3. 条件(? : )运算符
  4. 逗号运(, )算符

运算对象的求值顺序与优先级和结合律无关。

建议:处理复合表达式:

  1. 拿不准的时候最好用括号来强制让表达式的组合关系符合程序的逻辑要求
  2. 如果改变了某个运算对象的值,在表达式的其他地方不要再使用这个运算对象。当改变运算对象的子表达式本身就是另一个子表达式的运算对象时该规则无效:*++iter。

4.2 算术运算符

算术运算符(左结合律)功能用法
+一元正号+ expr
-一元负号- expr
*乘法expr * expr
/除法expr / expr
%求余expr % expr
+加法expr + expr
-减法expr - expr

除非另作特殊说明,算术运算符都能用作与任意算术类型以及任意能转换为算术类型的类型。

算术运算符的运算对象和求值结果都是右值。

一元正号运算符、加法运算符和减法运算符都能作用于指针。当一元正号运算符作用于一个指针或算术值时,返回运算对象值的一个(提升后的)副本。一元负号运算符对运算对象值取负后,返回其(提升后的)副本:

int i = 1024;
int k = -i;		//k是-1024
bool b = true;
bool b2 = -b;	//b2是true
//布尔值不应该参与运算,-b就是一个很好的例子。

算术表达式可能产生未定义的结果:

  • 一部分原因是数学性质本身:例如除数是0的情况
  • 另一部分则源于计算机的特点:例如溢出,当计算的结果超过该类型所能表示的范围就会产生溢出

C++语言早期版本允许结果为负值的商向上或向下取整,C++11新标准规定商一律向0取整(即直接切除小数部分)

根据取余运算的定义:如果m和n是整数且n非0,则表达式(m/n)*n + m%n 的求值结果与m相等。隐含的意义是,如果m%n不等于0,则它的符号和m相同。

C++新标准已经禁止m%n的符合匹配n的符号,除了-m导致溢出的特殊情况,其他时候:

(-m)/n == m/(-n) == -(m/n);
m%(-n) == m%n;
(-m)%n == -(m%n);

4.3 逻辑和关系运算符

关系运算符作用于算术类型或指针类型,逻辑运算符作用于任意能转换成布尔值的类型,它们的运算对象和求值结果都是右值。

逻辑运算符和关系运算符的返回值都是布尔类型。值为0的运算对象(算术类型或指针类型)表示假,否则表示真。

结合律运算符功能用法
!逻辑非!expr
<小于expr < expr
<=小于等于expr <= expr
>大于expr > expr
>=大于等于expr >= expr
==相等expr == expr
!=不相等expr != expr
&&逻辑与expr && expr
||逻辑或expr || expr

逻辑与和逻辑或运算符都是先求左侧运算对象的值再求右侧运算对象的值,当且仅当左侧运算对象无法确定表达式的结果时,才会计算右侧运算对象的值。这种策略称为短路求值

if (val) {/*...*/}	//如果val是任意非0值,条件为真
if (!val) {/*...*/}	//如果val为0,条件为真
//试图将以上写法写出下面形式:
if (val == true) {/*...*/}	//只有当val等于1时条件才为真

这种改写与之前代码相比:

  • 写法较长而且不太直接
  • 如果val不是布尔值,这样的比较就失去了原来的意义,true会被转换成val的类型

进行比较运算时,除非比较的对象是布尔类型,否则不要使用布尔字面值true和false作为运算对象

4.4 赋值运算符

赋值运算符的左侧运算对象必须是一个可修改的左值,赋值运算的结果是它的左侧运算对象,并且是一个左值,结果的类型是左侧运算符对象的类型。如果赋值运算符的左右两个运算对象类型不同,则右侧运算对象将转换成左侧运算对象的类型。

C++11新标准允许使用花括号括起来的初始化列表作为赋值语句的右侧运算对象。

如果左侧运算对象是内置类型,那么初始化列表最多只能包含一个值,而且该值即使转换的话其所占空间也不应该大于目标类型的空间:

int k;
k = {3.14};	//错误:窄化转换
vector<int> vi;
vi = {0,1,2,3,4,5,6,7,8,9};

无论左侧运算对象的类型是什么,初始值列表都可以为空,此时编译器创建一个值初始化的临时变量并将其赋给左侧运算对象。

赋值运算符满足右结合律,靠右的赋值运算作为靠左的赋值运算符的右侧运算对象。

因为赋值运算符的优先级低于关系运算符,所以在条件语句中,赋值部分通常应该加上括号。

4.5 递增和递减运算符

递增和递减有两种形式:(作用于左值运算对象

  • 前置版本:将运算对象加1(或减1),然后将改变后的对象作为求值结果,将对象本身作为左值返回
  • 后置版本:将运算对象加1(或减1),但是求值结果是运算对象改变之前那个值的副本,将对象原始值的副本作为右值返回

除非必须,否则不用后置版本的递增递减运算符。

4.6 成员访问运算符

点运算符和箭头运算符都可用于访问成员,其中,点运算符获取类对象的一个成员,而箭头运算符与点运算符有关,表达式ptr->mem等价于(*ptr).mem。解引用运算符优先级低于点运算符。

箭头运算符作用于一个指针类型的运算对象,结果是一个左值。

点运算符:

  • 如果成员所属的对象是左值,那么结果是左值
  • 如果成员所属的对象是右值,那么结果是右值

4.7 条件运算符

条件运算符( ? : )允许我们把简单的if-else逻辑嵌入到单个表达式中,条件运算符使用形式:

cond ? expr1 : expr2;
//等价于
if(cond)
	return expr1;
else
	return expr2;

当条件运算符的两个表达式都是左值或者能够转换成同一种左值类型时,运算结果是左值;否则运算结果是右值。

条件运算符满足右结合律。条件运算符可以嵌套使用,也就是条件表达式可以作为另一个运算符的cond或expr。随着嵌套层数增加,代码的可读性急剧下降,因此最好不要超过两到三层。

条件运算符的优先级非常低,因此当一条长表达式中嵌套了条件运算符子表达式时,通常需要在它两端加上括号:

cout << ((grade < 60) ? "fail" : "pass");	//输出pass或者fail

cout << (grade < 60) ? "fail" : "pass";		//输出1或者0
//等价于:
cout << (grade < 60);
cout ? "fail" : "pass";

cout << grade < 60 ? "fail" : "pass";		//错误:试图比较cout和60
//等价于:
cout << grade;
cout < 60 ? "fail" : "pass";

4.8 位运算符(左结合律)

位运算符作用于整数类型的运算对象,并把运算对象看出是二进制位的集合。位运算符提供检查和设置二进制位的功能。

标准库类型bitset可以表示任意大小的二进制位集合,位运算符同样适用于bitset类型。

运算符功能用法
~位求反~ expr
<<左移expr1 << expr2
>>右移expr1 >> expr2
&位与expr & expr
^位异或expr ^ expr
|位或expr | expr

如果运算对象是“小整型”,则它的值会被自动提升成较大的整型。运算对象可以是带符号的,也可以是无符号的,如果运算对象是带符号的且它的值为负,那么位运算符如何处理运算对象的“符号位”依赖于机器。而且,此时的左移操作可能会改变符号位,因此是未定义的行为。

二进制位左移或者右移,移出边界之外的位数就被舍弃了。左移运算符(<<)在右侧插入值为0的二进制位;右移运算符的行为依赖于左侧运算对象的类型:

  • 如果运算对象是无符号类型,在左侧插入值为0的二进制位
  • 如果运算对象是带符号类型,在左侧插入符号位的副本或者值为0的二进制位,如何选择要视具体环境而定

移位运算符(又叫IO运算符)满足左结合律,尽管很多人没直接用过移位运算符,但是几乎都用过它们的重载版本来进行IO操作。重载运算符的优先级和结合律都与它的内置版本一样

移位运算符的优先级不高不低,介于中间:

  • 比算术运算符的优先级低
  • 比关系运算符、赋值运算符和条件运算符优先级高

4.9 sizeof运算符

sizeof运算符返回一条表达式或一个类型名字所占的字节数。sizeof运算符满足右结合律,其所得值是一个size_t类型的常量表达式。

运算符的运算对象有两种形式:

sizeof (type)
sizeof expr		//返回表达式结果类型的大小

sizeof并不实际计算运算对象的值,所以在sizeof的运算对象中解引用一个无效指针仍然是一种安全行为,因为指针实际上并没有被使用,sizeof不需要真正的解引用指针也能知道它所指对象的类型。

C++11新标准允许我们使用作用域运算符来获取类成员的大小,通常情况只有通过类的对象才能访问到类的成员,但是sizeof运算符无须我们提供一个具体的对象,因为要想知道类成员的大小无须真的获取该成员。

sizeof运算符的结果部分地依赖于其作用的类型:

  • 对char或者类型为char的表达式执行sizeof运算,结果得1
  • 对引用类型执行sizeof运算得到被引用对象所占的空间大小
  • 对指针指向sizeof运算得到指针本身所占空间的大小
  • 对解引用指针执行sizeof运算得到指针指向的对象所占空间大小,指针不需要有效
  • 对数组执行sizeof运算得到整个数组所占空间的大小,sizeof运算不会把数组转换成指针来处理,数组大小除以单个元素的大小可以得到数组元素的个数
  • 对string对象或者vector对象执行sizeof运算只返回该类型固定部分的大小,不会计算对象中元素占用了多少空间

因为sizeof的返回值是一个常量表达式,所以可以用其结果声明数组维度。

4.10 逗号运算符

逗号运算符含有两个运算对象,按照从左向右的顺序依次求值。逗号运算符先对左侧的表达式求值,然后将求值结果丢弃掉。逗号运算符真正的结果是右侧表达式的值,如果右侧运算对象是左值,那么求值结果也是左值。

4.11 类型转换

在C++语言中,某些类型之间有关联。如果两种类型有关联,那么当程序需要其中一种类型的运算对象时,可以用另一种关联类型的对象或值来替代。如果两种类型能够相互转换,它们就是关联的

int ival = 3.541 + 3;	//存在隐式转换

算术类型之间的隐式转换被设计成尽可能避免损失精度

3被隐式转换成double型,然后执行加法运算,计算结果是double型,初始化过程中,初始化对象类型无法改变,则计算结果由double型转换成int型,忽略了小数部分。

何时发生隐式类型转换:

  • 在大多数表达式中,比int类型小的整数型值首先提升为较大的整数类型
  • 在条件中,非布尔值转换成布尔类型
  • 初始化过程中,初始值转换成变量的类型:在赋值语句中,右侧运算对象转换成左侧运算对学校的类型
  • 如果算术运算或关系运算的运算对象有多种类型,需要转换成同一种类型
  • 函数调用也会发生类型转换

4.11.1 算术转换

整型提升负责把小整数类型转换成较大的整数类型。转换后的类型要能容纳原类型所有可能的值。

如果一个运算对象是无符号类型,另一个运算对象是带符号类型:

  • 无符号类型不小于带符号类型(存储空间大小),那么带符号类型的运算对象转换成无符号的,例如:unsigned int和int,则int类型的运算对象转换成unsigned int
  • 带符号类型大于无符号类型,转换依赖于机器:
    • 如果无符号类型的所有值都能存在该带符号类型中,则无符号类型的运算对象转换成带符号类型,例如long和unsigned int,并且int和long大小相同,则long类型运算对象转换成unsigned int
    • 如果不能,则带符号类型的运算对象转换成无符号类型,即long类型占用的空间比int更多,则unsigned int类型的运算对象转换成long类型

4.11.2 其他隐式类型转换

数组转换成指针:在大多数用到数组的表达式中,数组自动转换成指向数组首元素的指针。当数组被用作decltype关键字的参数、作为取地址符(&)、sizeof以及typeid等运算符的运算对象时,转换不发生。

指针的转换:常量整数值0或者字面值nullptr能转换成任意指针类型;指向任意非常量的指针能转换成void*;指向任意对象的指针能转换成const void*。

转换成布尔类型:存在一种从算术类型或指针类型向布尔类型自动转换的机制。如果指针或算术类型的值是0,转换结果是false,否则转换结果是true。

转换成常量:允许将指向非常量类型的指针转换成指向相应的常量类型的指针,对于引用也是这样。相反的转换并不存在。

类类型定义的转换:类类型能定义由编译器自动执行的转换,不过编译器每次只能执行一种类型的转换。

4.11.3 显示转换

强制类型转换(cast):显示的将对象强制转换成另外一种类型。

命名的强制类型转换的形式为:cast-name(expression),其中,type是转换的目标类型而expression是要转换的值。如果type是引用类型,则结果是左值。cast-name有四种:

  1. static_cast:任何具有明确定义的类型转换,只要不包含底层const,都可以使用static_cast。可以用static_cast把一个较大的算术类型赋值给较小的类型,还可以将void*指针强制转换回原来的类型。

    int j = 1;
    double slope = static_cast<double>(j);
    
    void *p = &d;
    double *dp = static_cast<double*>(p);
    
  2. const_cast:只能改变运算对象的底层const。如果对象本身不是一个常量,使用强制转换获得写权限是合法的行为。

    const char *pc;
    char *p = const_cast<char*>(pc);
    

    只有const_cast能改变表达式的常量属性,使用其他形式的命名强制类型转换改变表达式的常量属性都将引发编译器错误。同样,也不能使用const_cast改变表达式的类型:

    const char *cp;
    char *q = static_cast<char*>(cp);	//错误,不能用static_cast转换掉const性质
    static_cast<string>(cp);		//正确:字符串字面值转换成string类型
    const_cast<string>(cp);			//错误:const_cast只改变常量属性
    

    const_cast常常用于函数重载的上下文。

  3. reinterpret_cast:通常为运算对象的位模式提供较低层次上的重新解释。

    int *ip;
    char *pc = reinterpret_cast<char*>(ip);
    string str(pc);	//可能导致异常的运行时行为
    

    pc所指的真实对象是一个int而非字符,如果把pc当成普通的字符指针使用就可能在运行时发生错误。使用reinterpret_cast是非常危险的,其本质上依赖于机器,必须对涉及的类型和编译器实习转换的过程都非常了解,才能安全地使用reinterpret_cast。

  4. dynamic_cast

应该尽量避免强制类型转换!

旧式的强制类型转换:在早期版本的C++语言中,显示地进行强制类型转换包含两种形式

type(expr);			//函数形式的强制类型转换
(type)expr;			//C语言风格的强制类型转换

根据所涉及的类型不同,旧式的强制类型转换分节具有与const_cast、static_cast或reinterpret_cast相似的行为。

与命名的强制类型转换相比,旧式的强制类型转换从表现形式上来说不那么清晰明了,容易被看漏,所以一旦转换过程出现问题,追踪起来也更加困难。

4.12 运算符优先级表

image-20200613141520895

image-20200613141550448